이벤트 삼대장 캡쳐링, 버블링, 위임을 알아보자

📅 2022. 02. 26

이벤트 캡쳐링과 버블링

이벤트 캡쳐링, 버블링 한 짤 요약

자바스크립트의 이벤트는 전파됩니다. 요소를 클릭했을 때 요소에만 이벤트가 발생할 것 같지만, 사실은 window에서부터 내려와서 요소에 이벤트가 전파되고, 다시 window를 향해 계층적으로 이벤트는 전파됩니다.

왜 이벤트는 전파될까요? 이벤트 전파 개념은 마우스 클릭과 같이 상위-하위 관계가 있는 DOM 계층의 여러 요소가 동일한 이벤트에 대한 이벤트 핸들러를 갖는 상황을 다루기 위해 도입되었습니다. 만약 사용자가 div안의 button을 누르면 div의 클릭 이벤트와 button 클릭 이벤트 중에 어떤게 먼저 발생할까요? 한 번 코드를 통해 알아보도록 하겠습니다. 아래는 HTML 예시입니다.

<div class="container">
  <ul id="list">
    <li>
      <button class="btn">item 1</button>
    </li>
    <li>
      <button class="btn">item 2</button>
    </li>
  </ul>
</div>

캡쳐링/버블링을 확인하기 위해 div, button DOM에 각각 addEventListener를 걸어주고 console.log를 띄워보겠습니다.

<script>
  function onClick(e) {
    console.log(`target (이벤트를 발생시킨 돔)`, e.target);
    console.log(`eventPhase (전파 단계, 1 캡쳐링, 2 타겟, 3 버블링)`, e.eventPhase);
    console.log(`currentTarget (이벤트를 처리하는 돔)`, e.currentTarget);
  };

  const container = document.querySelector('.container');
  container.addEventListener("click", onClick, true);

  const buttons = document.querySelectorAll('.btn');
  buttons.forEach(el => el.addEventListener("click", onClick, false));
</script>

container와 모든 button 요소에 클릭 이벤트를 등록하고 전파 단계와 현재 타겟을 출력해봤습니다. 참고로 containeraddEventListener 세번째 인자는 useCapture 입니다. useCapture의 기본값은 false이며 true로 설정해야만 캡쳐링 단계에서 이벤트가 실행됩니다. 캡쳐링이 실제로 일어나는지 봐야하기 때문에 true로 설정합시다.

container.addEventListener("click", onClick, true);

이제 html을 저장하고 item 1 버튼을 눌러보겠습니다.

event.eventPhase를 통해 이벤트 전파 단계를 확인할 수 있습니다. 1 (캡쳐링) → 2 (타겟) 순서로 로그가 출력되고 있으며 이를 통해 상위에서 하위로 이벤트가 전파된다는 것을 확인할 수 있습니다.

event.targetevent.currentTarget은 각각 이벤트를 발생시킨 DOM과 현재 이벤트 로직을 처리하고 있는 DOM을 가르킵니다. 로그를 통해 버튼이 이벤트를 발생시켰고 div -> button 순서대로 이벤트를 처리중인걸 알 수 있습니다.

이번엔 container DOM의 useCapture 옵션을 꺼보겠습니다.

function onClick(e) {
  console.log(`target (이벤트를 발생시킨 돔)`, e.target);
  console.log(`eventPhase (전파 단계, 1 캡쳐링, 2 타겟, 3 버블링)`, e.eventPhase);
  console.log(`currentTarget (이벤트를 처리하는 돔)`, e.currentTarget);
};

const container = document.querySelector('.container');
-  container.addEventListener("click", onClick, true);
+  container.addEventListener("click", onClick, false);

const buttons = document.querySelectorAll('.btn');
buttons.forEach(el => el.addEventListener("click", onClick, false));

다시 html을 저장하고 item 1 버튼을 눌러보겠습니다. 어떤 결과가 나올까요?

event.eventPhase를 통해 2 (타겟) → 3 (버블링) 순서로 로그가 출력되고 있으며 이를 통해 하위에서 상위로 이벤트가 전파된다는 것을 확인할 수 있습니다. 두 예제를 실행하면서 알수있는 점은 useCapturetrue/false 여부에 상관없이 이벤트는 한 번만 실행된다는 점입니다.

이벤트 위임(event delegation)

캡처링과 버블링을 활용하면 강력한 이벤트 핸들링 패턴인 이벤트 위임(event delegation) 을 구현할 수 있습니다. 이벤트 위임은 비슷한 방식으로 여러 요소를 다뤄야 할 때 사용됩니다. 이벤트 위임을 사용하면 요소마다 핸들러를 할당하지 않고, 요소의 공통 조상에 이벤트 핸들러를 단 하나만 할당해도 여러 요소를 한꺼번에 다룰 수 있습니다. - 이벤트 위임, 모던 JavaScript 튜토리얼, https://ko.javascript.info/event-delegation

이벤트 위임을 사용하면 메모리 절약으로 인한 성능적인 이점을 누릴 수 있습니다. 이해를 돕기 위해 이벤트 위임을 사용하지 않은 예제와 사용한 예제를 보여드리겠습니다. 예제는 Add 버튼을 누르면 짧은 텍스트와 자기 자신을 삭제할 수 있는 버튼을 생성하는 코드입니다.

<div class="container">
  <div>
    <button id="add">Add</button>
  </div>
  <ul id="list">
  </ul>
</div>

<script>
  function handleRemove(e) {
    e.target.closest('li').remove();
  }

  function addItem() {
    const container = document.querySelector('.container');
    const item = document.createElement('li');
    item.classList.add("item");

    const itemContainer = document.createElement('p');
    itemContainer.textContent = 'Hello';
    const removeButton = document.createElement('button');
    removeButton.classList.add("remove");
    removeButton.textContent = 'Remove';
    removeButton.addEventListener("click", handleRemove);
    itemContainer.appendChild(removeButton);

    item.appendChild(itemContainer);
    container.appendChild(item);
  }

  const addButton = document.getElementById('add');
  addButton.addEventListener("click", addItem);
</script>

이 코드는 DOM 개수에 비례해서 이벤트 리스너의 개수가 늘어나기 때문에 그만큼 메모리에 차지하는 공간도 비례해서 늘어나는 문제가 있습니다. 더군다나 IE 같은 구형 브라우저에서는 엘리먼트가 삭제될 때 코드에서 이벤트 리스너를 지워주지 않으면 메모리 누수(Memory leak)을 발생시키기도 합니다. 따라서 상위 DOM에 이벤트 리스너를 하나 만들어서 공통으로 로직을 처리하는게 일일이 리스너를 만드는 것보다 성능 면에서 좋다고 볼 수 있습니다. 다음은 위의 로직을 이벤트 위임 방식으로 변경한 코드 예제입니다.

function handleRemove(e) {
+  if (e.target.className === 'remove') {
    e.target.closest('li').remove();
+  }
}

function addItem() {
  const container = document.querySelector('.container');

  const item = document.createElement('li');
  item.classList.add("item");

  const itemContainer = document.createElement('p');
  itemContainer.textContent = 'Hello';
  const removeButton = document.createElement('button');
  removeButton.classList.add("remove");
  removeButton.textContent = 'Remove';
-  removeButton.addEventListener("click", handleRemove);
  itemContainer.appendChild(removeButton);

  item.appendChild(itemContainer);
  container.appendChild(item);
}

+ const container = document.querySelector('.container');
+ container.addEventListener("click", handleRemove);

const addButton = document.getElementById('add');
addButton.addEventListener("click", addItem);

부모 엘리먼트 container에게 이벤트 처리 로직을 할당해서 이벤트 리스너의 숫자를 n개 -> 1개로 줄였습니다. 사소하긴 하지만 브라우저의 소중한 메모리를 절약할 수 있었습니다.

React에서도 이벤트 위임을 해야할까?

아니요! 하지 않아도 됩니다.

React 깃허브 이슈를 보면 개발자 분이 이벤트 위임이 필요한가에 대해 답변을 남겨두었습니다.

React doesn't attach your click event handlers to the nodes. It uses event delegation and listens at the document level.

React는 내부적으로 document 레벨에서 이벤트 위임을 처리하기 때문에 별도로 이벤트 위임을 위한 작업을 하지 않아도 됩니다.

참고로 React v17에선 document 레벨에서 처리하던 이벤트 위임을 root 레벨에서 처리하는 걸로 변경되었습니다. CHANGELOG

결론

  • 자바스크립트의 이벤트는 전파된다.
  • 이벤트 캡쳐링은 window ~ target으로 이벤트가 전파되는 단계이다.
  • 이벤트 버블링은 target ~ window로 이벤트가 전파되는 단계이다.
  • 이벤트 위임은 하위 DOM들에 생성할 이벤트 리스너 n개를 상위 DOM 단 1개에 위임하는 성능 최적화 전략이다.
  • React는 내부적으로 이벤트 위임을 하기 때문에 별도 이벤트 위임이 필요없다.